4.09. Dependency Injection
Dependency Injection (DI)
DI - это способ реализации DIP. Кстати, поэтому к Dependency Inversion и лучше добавить Principle, чтобы не путать их.
Dependency Inversion - это принцип проектирования, а Dependency Injection - паттерн проектирования. DIP говорит «что делать», DI - «как делать».
Dependency Injection решает вопрос - как передавать зависимости, а не создавать их внутри. Существует несколько видов внедрения зависимости:
- Constructor Injection (через конструктор).
- Setter Injection (через сеттер).
- Field Injection (через поле).
- Property Injection (через свойства).
- Method Injection (через метод).
Как раз способ, показанный со Switch - это конструктор. В классе нужно написать поле с типом данных абстракции, и в конструкторе добавить аргумент с сопоставлением поля и переменной. Как это работает? Давайте на примере C#.
public class UserService
{
private readonly IUserRepository _repo;
public UserService(IUserRepository repo)
{
_repo = repo;
}
}
Внимание - обратите внимание на курсив и жирный шрифт в коде. В данном случае у нас есть класс UserService, которому создаётся поле _repo (нижнее подчёркивание добавлено для обозначения именно поля). У _repo, как можно заметить, тип данных - IUserRepository - некий интерфейс, который существует в коде.
Затем создаётся конструктор, который получает аргумент с типом данных IUserRepository и записывает в переменную repo. В теле метода уже указывается, что поле _repo будет иметь значение того самого аргумента - переменной repo. Поэтому _repo = repo. Это пример в C#, в .NET есть встроенный DI-контейнер. В Java используется DI, к примеру, в Spring:
@Service
public class UserService {
private final EmailService emailService;
public UserService(EmailService emailService) { // DI через конструктор
this.emailService = emailService;
}
}
Как можно заметить - сходство очень велико. Spring в данном случае автоматически решает работу сервиса.
Python, JavaScript (TypeScript, Node.js, Angular) специфичны, но тоже имеют такие возможности:
Python:
class UserService:
def __init__(self, email_service: EmailService):
self.email_service = email_service
JS:
class UserService {
constructor(private emailService: EmailService) {} // DI через конструктор
}
DI-контейнер (также называют IoC-контейнер) - это фреймворк или механизм, который автоматически создаёт и внедряет зависимости. Он выполняет следующие задачи:
- Регистрирует типы (к примеру, IEmailService и его наследник SmptEmailService);
- Создаёт объекты (с учётом зависимостей);
- Разрешает зависимости (внедряет нужные объекты);
- Управляет жизненным циклом (singleton, transient, scoped).
Пример, как мы упомянули с IEmailService и SmtpEmailService:
container.register<IEmailService, SmtpEmailService>();
container.register<UserService>();
UserService userService = container.resolve<UserService>();
// → контейнер сам создаст SmtpEmailService и передаст в UserService
Так, благодаря DI, мы получаем возможность подмены реальных сервисов на тестируемые элементы (моки, стабы), можем менять реализации, снижаем связанность (классы не зависят от конкретных реализаций), можем повторно использовать компоненты в разных контекстах (вспомним Switch с разными девайсами), и получаем централизованное управление всеми зависимостями в DI-контейнере.
interface EmailService {
void send(String msg);
}
class SmtpEmailService implements EmailService { ... }
class MockEmailService implements EmailService { ... } // для тестов
class UserService {
private EmailService emailService;
public UserService(EmailService emailService) { // DI
this.emailService = emailService;
}
public void register(User user) {
// ... логика
emailService.send("Welcome!");
}
}
Но мы разбираем, по сути, только внедрение через конструктор. А как же с другими? Давайте по порядку.
Setter Injection (внедрение через сеттер) подразумевает, что зависимость внедряется после создания объекта через сеттер-метод. Это используется, когда зависимость не обязательна (опциональна), когда объект может существовать без зависимости, но позже её можно подключить, и встречается в legacy-кодах или фреймворках, где конструктор уже занят.
public class UserService {
private EmailService emailService;
// Сеттер для внедрения зависимости
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
public void register(User user) {
emailService.send("Welcome!"); // используем
}
}
В Java (Spring) есть такое понятие - bean, которое позволяет записать связь UserService с свойством emailService, в результате чего Spring вызывает setEmailService() автоматически:
<bean id="userService" class="UserService">
<property name="emailService" ref="emailService"/>
</bean>
C# выглядит похожим образом
public class UserService
{
private IEmailService _emailService;
public void SetEmailService(IEmailService emailService) // DI через сеттер
{
_emailService = emailService;
}
}
На первый взгляд, вариант DI через сеттер ОЧЕНЬ похож на вариант конструктора, но тут есть отличия. Давайте наглядно:
| Критерий | DI через конструктор | DI через сеттер |
|---|---|---|
| Механизм внедрения | Используется конструктор класса. | Используется отдельный метод (сеттер). |
| Обязательность зависимости | Обязательная. Класс не может быть создан без предоставления зависимости. | Опциональная. Объект можно создать без зависимости, но она потребуется при вызове соответствующего метода. |
| Изменяемость | Иммутабельная. Зависимость устанавливается один раз при создании объекта и не может быть изменена (особенно если поле readonly). | Мутабельная. Зависимость может быть заменена в любой момент времени после создания объекта. |
| Гарантия инициализации | Высокая. Компилятор гарантирует, что зависимость будет передана. Отсутствие зависимости приведёт к ошибке на этапе компиляции или отказу в создании экземпляра. | Низкая. Нет гарантий, что сеттер будет вызван. Если зависимость не установлена, это может привести к NullReferenceException во время выполнения. |
| Явность требований | Высокая. Все необходимые зависимости явно указаны в сигнатуре конструктора. Сразу понятно, что требуется для работы класса. | Низкая. Требования к зависимостям не очевидны из сигнатуры конструктора. Необходимо изучать документацию или код, чтобы узнать, какой сеттер нужно вызвать. |
| Основное применение | Когда зависимость является критичной для функционирования класса и должна быть определена с момента его создания. Предпочтительный способ внедрения зависимостей. | Когда зависимость является опциональной, может быть добавлена позже или должна иметь возможность быть переконфигурированной во время жизненного цикла объекта. |
Property Injection (внедрение через свойства) часто бывает как синоним Setter Injection), особенно в .NET. Технически, в .NET и некоторых DI-контейнерах «свойство» (property) = public setter. То есть, DI-контейнер автоматически может устанавливать свойства, если у них есть сеттер:
public class UserService
{
public IEmailService EmailService { get; set; } // автоматически внедряется
}
Контейнер, к слову выглядеть будет так:
services.AddTransient<UserService>();
Если EmailService зарегистрирован, контейнер сам установит свойство, если оно доступно для записи. Это удобно для необязательных сервисов и предусматривает автоматизацию. Минусы здесь те же, что и у сеттера - объект может быть не полностью сконфигурирован и менее предсказуемый. Словом, это некий «частный случай» инъекции через сеттер, но реализованный «магически» контейнером.
Внедрение через поле (Field Injection) подразумевает, что зависимость напрямую внедряется в поле объекта, минуя конструктор и сеттеры. Часто используется с аннотациями и декораторами:
@Service
public class UserService {
@Autowired
private EmailService emailService; // внедряется в поле!
}
Но Field Injection считается антипаттерном и рекомендуется такой способ избегать. При этом невозможно протестировать без DI-контейнера, зависимости не видны (согласитесь - это уже не так очевидно?), нельзя сделать поле final / readonly.
Method Injection (внедрение через метод) подразумевает, что зависимость передаётся в метод, а не хранится в объекте. Используется, когда зависимость нужна только для одного вызова, меняется от вызова к вызову или когда хочется избежать хранения состояния.
public class UserService {
public void register(User user, EmailService emailService) { // зависимость в методе
// ... логика
emailService.send("Welcome!");
}
}
Логика здесь слегка размазывается, а вызывающий код должен знать, какую реализацию передавать. Это не подходит, если зависимость используется во множестве методов. Это не совсем DI в своём классическом ООП-смысле, скорее передача параметра, но формально, это вид инъекции зависимости в метод.
Итого, если рассмотреть что-то в конечном смысле, лучше всегда и по умолчанию использовать именно конструктор, а инъекция через поле - плохая практика. В функциональных или простых сценариях можно использовать внедрение через метод.